These are the RenewCMS components, built from the primitives on the first page. Each one lists the Nuxt UI parts it's made from, its props, emits and slots, every state, and keyboard and responsive behavior. Components marked custom have no Nuxt UI equivalent.
One centered card for every public screen. The logo sits above the title, and the form or ResultPanel goes in the slot. Footer links and the theme select sit outside the card, so the card itself stays about one task.
AuthShell.vue: a UPageCard variant="outline" inside a centered main, with AppLogo, and UColorModeSelect size="sm" in the footer. Forms inside it use UForm rather than UAuthForm, because every screen needs states (such as 403, 429 and an inline login during verification) that UAuthForm doesn't expose.
Props, slotsProps title: string, description?: string. Slots default, notice (an alert above the title, for session expired or password changed), footer (links). No emits.
A11yThe title is the page's h1, and document.title becomes "Log in · RenewCMS". On route change, focus moves to the h1. The logo is alt="RenewCMS" and isn't a link, since there's nothing public to go back to.
Responsive400 px card from sm up, with its top 12 vh from the top. Below sm, the card loses its border and padding drops to 20 px, filling the screen on --ui-bg, and fields and buttons use xl.
The sidebar has three destinations, and the user menu sits at its foot. The top bar carries the breadcrumbs and page title. Collapsing the sidebar leaves a 56 px icon rail. Below lg, the sidebar becomes a slideover.
UDashboardGroup storage="local" › UDashboardSidebar collapsible :resizable="false" :default-size="16" :collapsed-size="4" with the #header (AppLogo), #default (UNavigationMenu orientation="vertical" :collapsed) and #footer (UserMenu) slots. Each page is a UDashboardPanel, with a UDashboardNavbar holding UDashboardSidebarCollapse and UBreadcrumb. On mobile, UDashboardSidebarToggle appears and the sidebar's built-in slideover takes over.
UserMenuUDropdownMenu :content="{ side: 'top', align: 'start' }" on a ghost UButton block with a UAvatar and the username. The groups are: the account label; System, Light and Dark as checkbox items; then Log out. There are no profile or password items, because the API doesn't support them.
Props, slotsNo props, because the shell reads the user from the session Pinia store. The navbar reads route.meta.title and route.meta.breadcrumb. Slots navbar-right (page actions on mobile) and default. It shows the offline banner when navigator.onLine is false.
A11yThe first Tab stop is a "Skip to content" link. The sidebar is a nav labeled "Main", and the current page gets aria-current="page". In the collapsed rail, items show tooltips and keep their aria-label. The collapse button carries aria-expanded. The mobile slideover traps focus and closes after navigating.
BendThe active nav item uses a sand fill with ink text instead of Nuxt UI's primary-tinted background and left indicator. The shell is set up for this in ui.config.ts.
UPageHeader :title :description, with actions in the #links slot. The ui override is { root: 'py-0 border-0', title: 'text-xl', description: 'text-sm text-muted mt-0.5' }, which brings Nuxt UI's marketing-size header down to dashboard density.
Props, slotstitle, description?, slot actions (the primary action goes last, so it sits on the right), slot meta (for example, a status badge next to the title in the editor).
ResponsiveBelow md, the actions wrap under the text. The primary action goes full width at 44 px, and secondary actions move into the navbar's overflow menu.
UBadge. Online: color="neutral" variant="solid" icon="i-lucide-globe". Draft: color="neutral" variant="outline" icon="i-lucide-pencil-line". Status is shown by the fill, the icon and the word, never by color alone.
Propsonline: boolean, size?: 'sm' | 'md' (sm in lists, md in the editor header), pending?: 'publish' | 'unpublish' (shows the -ing label with a spinner while the request runs). No emits, because it isn't interactive.
Search, filter tabs, a sortable table and row actions. Everything happens in the browser on the list it has already loaded. Below md, the table becomes a card list with the same data and actions.
UTabs + UInput type="search" + UTable (desktop), or a custom ArticleCardList of ULink rows (mobile), switched with useBreakpoints at md. Each row has a UDropdownMenu, and the row actions go through useArticleActions(), which shares one set of calls between the table, the list and the editor.
Props, emitsProps articles: Article[], loading: boolean, busyIds: Set<number>. Emits publish(id), unpublish(id), delete(article), copy-url(id). Search, tab and sort state lives in the URL query (?q=&status=&sort=). Slots #empty, #toolbar-right.
BehaviorSearch is case-insensitive and ignores accents, and it matches anywhere in the title. It updates on input, debounced 120 ms. Tab counts always reflect the full list. Dates within 7 days are relative ("2 hours ago"); older ones are absolute ("18 Aug 2026"), with the full timestamp in a title attribute. Clicking a row opens the editor, except clicks on the menu.
StatesLoading (5 skeleton rows), empty, no results, a filter tab with nothing in it, a row busy, an action failed (toast with Retry, row reverts), and a row vanished (a 404 on any action removes the row and shows an info toast). Row actions are never optimistic: the row updates when the 200 arrives.
A11yThe table has a visually hidden caption, "Articles, sorted by last updated, newest first". Each row's menu button is named "Actions for How we price a bathroom renovation". After a search, a polite live region announces "3 articles".
UEmpty :icon :title :description :actions variant="naked", placed in the UTable #empty slot or on the page. The icon tile is overridden to rounded-none bg-elevated.
Propsvariant: 'no-articles' | 'no-results' | 'no-drafts' | 'no-online', query?: string. Emits action. Each variant has fixed copy, and the search term is shown in quotes, cut to 40 characters.
A11yThe title is an h2. "No results" is announced through the table's live region. Clear search returns focus to the search field.
Left: default. Right: after a failed attempt (the error appears inline), with the retry running (loading).
UModal opened through useOverlay().create(ConfirmDialog). open() returns a promise that resolves once the action succeeds or the dialog is cancelled. The action runs inside the dialog, so errors show there instead of in a toast behind the overlay.
Props, emitstitle, description, confirmLabel, pendingLabel, tone: 'danger' | 'neutral', action: () => Promise<void>. Emits close(confirmed: boolean). The delete description comes from the title plus images.length, and reads "and its image" for 1 and just the title for 0.
Other usesCancel registration: "Cancel your registration?" / "Your account will be deleted. You can register again with the same or a different email." / "Cancel registration" / "Keep my account". Leaving with unsaved changes uses the neutral tone.
A11yrole="alertdialog". Focus starts on Cancel, and Enter doesn't confirm by default. While the action runs, the dialog can't be dismissed, both buttons are disabled, and aria-busy is set. On success, focus goes to the next row, or the page heading if there isn't one.
The editor is made of four components that work together. The header holds the read-only title, status, SaveStatus, Save and PublishControl. A sticky toolbar sits above the writing surface, which uses the content type scale. The counter sits at the foot.
Every bathroom is different, but the way we price them isn't. We start with a visit, measure everything, and write down what stays.
dirty, Save enabledsaving, Save disabledsaved, Save disablederror, changes keptdraft, savedonlinebody ≥ 90%over limit, Save disabledUEditor v-model content-type="html" :extensions + UEditorToolbar :editor :items layout="fixed". TipTap v3 StarterKit already includes Underline and Link. TextAlign handles alignment and Image handles images. Formatting is a UDropdownMenu with Paragraph and Headings 1–4 (h1 is kept because the public site controls how the title appears). Indent and outdent call sinkListItem/liftListItem in lists. For paragraphs, a small custom Indent extension writes data-indent="1–4", because TipTap has no paragraph indent. Clear formatting runs unsetAllMarks().clearNodes(). The editor surface gets class="article-content".
Props, emitsmodelValue: string (HTML), articleId: number, disabled?. Emits update:modelValue, request-image. Exposes insertImage(src, alt) and focus(). The toolbar reflects the active state (bold is on at the cursor) using aria-pressed.
SaveStatusA custom inline span aria-live="polite", driven by useArticleDraft(): 'clean' | 'dirty' | 'saving' | 'saved' | 'error'. Saved shows the time and switches to "Saved 5 min ago" after an hour. The error state's Retry is a UButton size="xs" variant="outline". Ctrl/⌘ S saves. Leaving with unsaved changes triggers onBeforeRouteLeave (ConfirmDialog, neutral tone) and beforeunload. There is no autosave, so the owner controls exactly what is sent. The draft is mirrored to sessionStorage so it survives a session expiry.
PublishControlA single UButton. Draft → primary "Publish"; draft with unsaved changes → "Save and publish" (PATCH body, then PATCH publish; if the save fails, it doesn't publish); online → outline "Unpublish". Unpublishing doesn't save pending changes, so the draft edits stay pending. Toasts: "Article published", with the description "Title" is now online.; "Article unpublished", It's a draft again and hidden from your website.
CharacterCounterA custom component in mono type. Props value: number, max: number, min?: number, warnAt?: number (default 0.9). The title uses [...str].length, so accents and emoji count as one, matching Go's validator, which counts runes. The body uses the HTML string length, which is what the API receives. The warning and error states add an icon and text, not just color. A visually hidden live region announces the change only when a threshold is crossed, not on every keystroke.
ResponsiveBelow lg, the details panel becomes a right slideover, opened with i-lucide-panel-right. Below md, the header wraps: back, title and badge on the first row, then SaveStatus and the counter. Save and Publish move to a sticky bottom bar (44 px). The toolbar scrolls horizontally with fade edges and keeps undo, redo and the image button pinned at the start.
Opens from the editor's image button in a small modal. Files are checked in the browser before anything is sent. After a successful upload the image goes in at the cursor and the modal closes.
idledragging overuploadingsuccess, closes after 600 msrejected: type or sizeserver errorUModal title="Add an image" › UFileUpload accept="image/png,image/jpeg,image/webp,image/svg+xml" :multiple="false" layout="list" + UProgress size="xs". Uploads go through XMLHttpRequest, because fetch has no upload progress, as FormData with the field name file. The 201 location is inserted with insertImage(location, '').
ValidationBefore uploading, the uploader checks both the MIME type and the file extension, and that size ≤ 10 × 1024 × 1024. The size error names the file size: "north-wall.tif is 14.2 MB. Images can be up to 10 MB." A server 400 shows the same message as a client-side rejection.
Props, emitsarticleId: number. Emits uploaded(location), close. A 401 hands off to the global session flow. A 404 means the article was deleted, which the editor handles.
A11y, touchThe drop zone is a labeled button, so Enter opens the file picker. Progress is exposed as role="progressbar" with aria-valuenow, and the result is announced. On touch screens the drop text is hidden and "Choose file" becomes the main action. Dropping a file directly onto the editor surface also opens the uploader with that file.
const res = await fetch('https://cms.renew.example/v1/article/12') if (res.status === 404) return notFound() const article = await res.json()
shiki (a dual github-light/github-dark theme remapped to the semantic colors: keywords info, strings success, numbers warning, comments muted). The header shows the language label and a ghost copy UButton, which switches to "Copied" for 2 s through useClipboard. Props code, lang, label?. Long lines scroll horizontally and never wrap.
EndpointCardUCard › a method UBadge (GET is solid ink) + the path in mono + a description, with UTabs (fetch, curl, Response) showing a CodeBlock each. Props method, path, description, examples: { fetch, curl, response }. Snippets use the current location.origin.
A11yThe copy button is named "Copy JavaScript example", and its result is announced. Code is a focusable pre (tabindex="0") so keyboard users can scroll it. Syntax colors pass 4.5:1 on --ui-bg-elevated in both themes.
not met yetmetnot met on submitreset screen onlyul in the password UFormField's #help slot. Props rules: { label: string, met: boolean }[], showErrors: boolean. There is no meter or score, because the API only requires 8 characters.
A11yThe list is the field's aria-describedby. Each item has hidden text reading "met" or "not met". A change is announced once, when the rule becomes met.
h1 title, description and UButton block size="lg" actions. Props tone: 'success' | 'error' | 'info', icon, title, description, actions: ButtonProps[]. Slot default for extras such as the inline login on verify.
A11yWhen the panel replaces a form, focus moves to its title and the panel is role="status", or role="alert" for errors. The countdown updates every second visually but is announced only at the start and when it reaches zero.
UError isn't used because it's built for Nuxt's error.vue. Instead there's a custom ErrorView (props kind: 'not-found' | 'error'), rendered inside UDashboardPanel when the user is logged in and inside AuthShell otherwise. The offline banner is a UBanner color="neutral" at --z-banner, shown and hidden with useOnline(). Once the connection returns it switches to "You're back online" for 3 s.
A11yThe error title is the page h1 and gets focus on render. The banner is role="status", and it can't be dismissed while offline. The offline state blocks Save and Publish and says why in their tooltip.